/*
Copyright (C) 2012 Brady Vidovic

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package forthelibrary;

import ftr.tools.PostProcessStarter;
import xbmcdb.db.tools.VideoType;
import xbmc.db.XBMCVideoDb;
import xbmc.db.XBMCVideoDbInterface;
import btv.http.BTVHTTP;
import btv.logger.BTVLogLevel;
import btv.tools.BTVTools;
import com.google.gson.Gson;
import com.google.gson.JsonPrimitive;
import ftr.gson.FTRGson;
import ftr.gson.ProcessingCommand;
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Scanner;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONStringer;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.filefilter.AbstractFileFilter;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.jdom.Document;
import org.jdom.Element;
import com.google.gson.reflect.TypeToken;
import ftr.gson.Recording;
import ftr.gson.Schedule;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import org.apache.commons.io.filefilter.PrefixFileFilter;
import org.json.JSONObject;
import ftr.tools.Constants;
import ftr.tools.TSJoiner;
import xbmc.util.XbmcCRC;
import xbmcdb.db.tools.XBMCDbConfig;

import static btv.tools.BTVConstants.*;
import static btv.tools.BTVTools.*;
import static xbmc.util.XbmcTools.*;


public class ForTheLibrary extends Constants
{
    //Configure the program to run as a single instance if possible (avoids log mash-ups) //TODO: improve logger to handle multi
    public static final boolean SINGLE_INSTANCE = true;
    public static final int SINGLE_INSTANCE_PORT = 55201;                
    public static final int SINGLE_INSTANCE_TIMEOUT_SECONDS = 60 * 10;//10 min. After waiting this long, will continue anyway        
    
    /**
     * Main method. Inits new instance of ForTheLibrary using args
     * @param args FTR recording files and/or directories to process
     */
    public static void main(String[] args)
    {                     
        
        try
        {                          
            ForTheLibrary f = new ForTheLibrary(args);            
            
            if(TESTING)
            {
                Logger.setLogLevel(BTVLogLevel.DEBUG);                
                List<File> recordings = new ArrayList<File>();
                recordings.add(new File(""/*enter path for testing here*/));
                f.processRecordings(recordings);
            }                                        
        }
        catch(Exception x)
        {
            Logger.ERROR("General Error: "+ x,x);
        }
        finally
        {
            Logger.NOTICE("------Finished------");
            if(Logger != null)
            {
                if(Config.EMAIL_NOTIFICATIONS)
                {
                    Logger.emailIf(Config.EMAIL_IF, 
                        BTVLogLevel.INFO, 
                        Config.SMTP_SERVER, 
                        Config.SMTP_PORT,
                        Config.SMTP_USES_SSL,
                        Config.SMTP_USERNAME,
                        Config.SMTP_PASSWORD,
                        Config.EMAIL_FROM_ADDRESS, 
                        Config.NOTIFICATION_RECIPIENTS, 
                        PROGRAM_NAME+" "+ Config.EMAIL_IF);
                }
                Logger.close();
            }
        }
    }
        
    //public
    public static final String STACK_PREFIX = "stack://";
    public static final String STACK_SEP = " , ";
    public static final SimpleDateFormat SDF_FTR_ISO = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZZ");
    
    //private
    private List<String> RECORDING_SHARES;//List of known recording shares from FTR's webservice
    private BTVHTTP HTTP = null;//HTTP getter/poster        
    private Gson gson;//gson object to use with FTR. See FTRGson
    private List<File> archivedRecordings = new ArrayList<File>();//keeps list of successfully archived recordings
    private File dbMultiToDoFile;//tracks pending stack:// integrations with XBMC's database
    private boolean filesWereArchived = false;//set to true if any recording are successfully archived
    /**
     * Initialize a new ForTheLibrary instance. Will init Logger, HTTP, Gson, dbMultiToDoFile, RECORDING_SHARES.<br>
     * Also does some connectivity and version checks with FTR's webservice.<br>
     * Will get a list of all recording files based on the <code>filesAndFolders</code> parameter and check them
     * to make sure they are inside a known recording share.
     * @param filesAndFolders
     * @throws Exception 
     */
    public ForTheLibrary(String[] filesAndFolders) throws Exception
    {        
        super(PROGRAM_NAME+".properties", LOGGING_LEVEL, SINGLE_INSTANCE, SINGLE_INSTANCE_PORT, SINGLE_INSTANCE_TIMEOUT_SECONDS);                                
        Logger.INFO(PROGRAM_NAME + " initialized. Version is: "+ PROGRAM_VERSION+"; compatible with ForTheRecord version: "+ SUPPORTED_FTR_VERSION);                
        
       
         
        //init HTTP
        HTTP = new BTVHTTP(true);//true to accept self-signed certs by default
        
        //init Gson
        gson = FTRGson.getFTRGson();      
        
        //init files
        dbMultiToDoFile = new File(BASE_DIR, "TodoXbmcDbStackFiles.txt");
        
        if(TESTING)
        {
            Logger.NOTICE("TESTING is true... not checking recordings against FTR shares...");
            return;
        }
        /*
         * DESIGN:                  
         *  
         * 
         * Conifg (properties file):
         X      Recording Shares
         *          -Get from FTR webservice
         *                      
         X      Recording video extension
         *          -list of extensions that are considered recordings
         * 
         X      FTR Core Services URL - used to update recording names/locations
         *          -http://FTR:49943 is default
         X              -Handle authentication and SSL
         * 
         X      Wait Time before execution
         *          -Seconds to wait from when this program is called (as a post-processing task)
         * 
         X      Artifact-delete
         *          -List of extensions to delete (.incommercial, etc)                  
         * 
         X      EDL Conversion
         *          -Yes/No and type to convert to
         * 
         X      XBMC Library Update
         *          -Yes/No and Max frequency
         * 
         X      Exclude
         *          Genres/SeriesNames to exclude
         * 
         * 
         * The Process
         *      Gets called as a post-processing task, 1 or more recordings paths are passed in as args
         *      -Determine what recording(s) to process based on what is passed in (dirs vs. files)
         *      -Wait the wait_time (if configured)         
         *      -Determine if TV Show/Movie (based on Genre)
         *      -For TV SHows
         *          -Determine SxxExx from .4tr
         *          -If SxxExx avail:
         *              Determine new name/location of file: Tv Shows/Series/season X/title (include year of series) check for multi-part SxxExx in desc.
         *              Create tvshow.nfo with tvdb url of series if it doesn't exists - http://thetvdb.com/index.php?tab=series&id=73545
         *          -If no season/episode info avail
         *              Use the series name as the folder name. Determine next Season-0 episode num avail and use it
         *              If no title availabe, use the air-date/recorded date as the title
         *              Create a .nfo using pertinent information from the .4tr file for the episode
         *              Create a .tbn using the .thmb if its avail.
         *              if series doesn't have a .nfo yet, create series nfo and generate thumbnail from recording (or use same one?)
         *              Create the necessary season-all/xxx.tbn for the seasons
         *      -For Movies
         *          Use new name of Movies/MovieTitle (year).ext
         *          Allow multi-part files if configured. (.part1, .part2 ...)
         * 
         *      Handle partial recordings/multi episodes when moving! -- XBMC TV Episodes can't be spread across multiple files. Movies Can
         *      -Perform artifact delete if enabled
         *      -Perform edl conversion if enabled
         *      Move to new destination:
         *        Rename the file locally using new name, update 4tr info using webservice/json
         *        Rename all sibling files to match the recording (get list of siblings before rename)
         * 
         *      At end of all args:         
         *          Trigger XMBC update if configured
         *          Start post-processing batch file if configured (don't wait for it).
         */
        
        if(filesAndFolders == null || filesAndFolders.length == 0)
        {
            Logger.ERROR( "No recording/directories were passed in as arguments. Nothing to do.");
            return;
        }
                        
        //determine what version of FTR we are connecting to
        String getVersionUrl = Config.FTR_WEBSERVICE_URL+"/Core/Version";
        String response = BTVHTTP.getResponseAsString(HTTP.POST(getVersionUrl, null));
        if(!valid(response))
        {
            Logger.ERROR(("Unable to get version from FTR by POSTing to: "+ getVersionUrl
                +LINE_BRK+"This indicates problem connecting to FTR webserver. Please check your config file."));            
        }
        else
        {
            response = response.trim().replace("\"", "");//"1.6.0.2"//remove quotes
            
            if(SUPPORTED_FTR_VERSION.contains(response))
                Logger.NOTICE("OK: ForTheRecord version "+ response + " found. This version has been tested and is fully supported.");
            else
                Logger.WARN("Found ForTheRecord version "+ response+", but this program has only been tested against version(s) "+ SUPPORTED_FTR_VERSION+". "
                        + "Compatibility cannot be guaranteed.");
        }                
        
        //get recording shares from ftr        
        String recordingSharesUrl = Config.FTR_WEBSERVICE_URL+"/Control/GetRecordingShares";
        try
        {
            RECORDING_SHARES = new ArrayList<String>();
            
            String jsonShares = BTVHTTP.getResponseAsString(HTTP.POST(recordingSharesUrl,null));
            //Sample return JSON Array: ["\\\\ONYX\\ForTheRecord\\Recordings"]
            JSONArray shareArray = new JSONArray(jsonShares);
            for(int i=0;i<shareArray.length();i++)
            {
                String share = shareArray.getString(i);     
                //trim any trailing slash to normalize
                share = trimTrailingSlashes(share);
                
                Logger.INFO("Found recording share: "+ share);
                RECORDING_SHARES.add(share);
            }
            
            if(RECORDING_SHARES.isEmpty())
                throw new Exception("No recording shares found.");
        }
        catch(Exception x)
        {
            Logger.ERROR( "Cannot continue. Failed to get recording shares from FTR webservice: "+ recordingSharesUrl,x);
            return;
        }
                                
        
        Set<File> recordingsToProcess = new LinkedHashSet<File>();//don't want duplicate files
        for(String fileOrDir : filesAndFolders)       
        {
            File f = new File(fileOrDir);
            if(!f.exists())
            {
                Logger.WARN( "Skipping because does not exist "
                        + "or access denied: "+ fileOrDir);
                continue;
            }
            
            if(f.isFile())
            {
                String ext = FilenameUtils.getExtension(f.getName());
                if(!Config.VIDEO_EXTS.contains(ext.toLowerCase()))
                {
                    Logger.WARN( "Skipping file because its extension: "+ ext +
                            " is not a valid video ext: "+ Config.VIDEO_EXTS);
                    continue;
                }
                if(!isInsideRecordingShare(f))
                {
                    Logger.WARN( "This file is not inside a recording share. "
                            + "Will not process: "+ f.getAbsolutePath()+LINE_BRK+
                            "Recording shares are: "+ RECORDING_SHARES);
                    continue;
                }
                if(isArchived(f))
                {
                    Logger.WARN( "This recording is already in an archived dir. "
                            + "Will not process: "+ f.getAbsolutePath());
                    continue;
                }
                
                Logger.INFO("Will process recording: "+ f.getAbsolutePath());
                recordingsToProcess.add(f);                
            }            
            else if(f.isDirectory())
            {
                Logger.INFO("Will process all non-archived recording in directory: "+ f.getAbsolutePath());
                List<File> recordingsFound = getRecordingsFromDirectory(f);
                Logger.INFO("Found "+ recordingsFound.size() +" recording(s) to process...");
                recordingsToProcess.addAll(recordingsFound);
            }
            else//not file or dir?
            {
                Logger.WARN( "Unknown file/directory. Skipping: "+ fileOrDir);
                continue;
            }
        }
        
        if(recordingsToProcess.isEmpty())
        {
            Logger.ERROR("No valid recordings found to process...");
        }
        else
        {
            if(Config.WAIT_SEC > 0)
            {
                Logger.INFO("Waiting "+ Config.WAIT_SEC +" seconds before "
                        + "processing "+recordingsToProcess.size() +" recording(s)...");
                Thread.sleep(Config.WAIT_SEC * 1000);                        
            }
            processRecordings(recordingsToProcess);                            
        }
    }
        
    
    /**
     * Private method that gets called from Constructor.<br>
     * For each recording file in <code>recordings</code>, will archive it in a format compatible with XBMC.     
     * @param recordings Collection of FTR recording files (UNC)
     */
    private void processRecordings(Collection<File> recordings)
    {                            
        toNextRecording://name the loop so we can easily continue
        for(File recordingFile : recordings)
        {
            Logger.NOTICE("Processing recording: "+ recordingFile.getAbsolutePath());
            try
            {                
                
                Recording recording = getRecording(recordingFile);    
                if(recording == null)
                    throw new Exception("No recording found from FTR webservice for path: "+recordingFile);
                                                
                //check for excluded genre
                String genre = recording.getCategory();
                if(valid(genre) && Config.EXCLUDED_GENRES.contains(genre.toLowerCase()))
                {
                    Logger.NOTICE("Skipping recording because its Genre \""+genre+"\" is set to be excluded.");
                    continue toNextRecording;
                }
                
                //check for excluded series name
                if(!recording.isMovie())
                {
                    String seriesName = recording.getSeriesName();
                    if(Config.EXCLUDED_SERIESNAMES.contains(seriesName.toLowerCase()))
                    {
                        Logger.NOTICE("Skipping recording because its Series \""+seriesName+"\" is set to be excluded.");
                        continue toNextRecording;
                    }
                }
                
                //handle partial recordings for episodes
                if(recording.isPatrialRecording())
                {                 
                    Logger.NOTICE("This is flagged by 4tr as a partial recording: "+ recordingFile);
                    if(!Config.PROCESS_PARTIAL_RECORDINGS)
                    {
                        Logger.WARN("Skipping partial recording because "
                                + "Config.PROCESS_PARTIAL_RECORDINGS="+Config.PROCESS_PARTIAL_RECORDINGS);
                        continue toNextRecording;                        
                    }
                    
                    boolean canBeSpreadAcrossMulipleFiles = 
                            recording.isMovie() || Config.JOIN_PARTIAL_RECORDINGS || Config.DB_ADD_PARTIAL_EPISODES;
                    
                    //for partial recordings, the original recording follows standard naming conventions,
                    //starts with __[1-9], then __[A-F], then _[10-?]. The length is always 3
                    final int multiIdentifierLength = "__X".length();
                    
                    //get all recordings for this group                                        
                    final String baseNameTrimmed;
                    if(recording.getBaseName().length() <= multiIdentifierLength)                    
                    {//probably will never happen
                        Logger.ERROR("Base name is too short! Found: "+ recording.getBaseName());
                        continue toNextRecording;
                    }
                    
                    //trim off last characters for comparison                    
                    //example names of partial recordings. We'll trim off the last 3 characters of both to get a "trimmed base name"
                    //Daniel Boone - Be Thankful for the Fickleness of Women (2012-03-01 0800)
                    //Daniel Boone - Be Thankful for the Fickleness of Women (2012-03-01 08__1
                    //Base Name Becomes:
                    //Daniel Boone - Be Thankful for the Fickleness of Women (2012-03-01 08
                    baseNameTrimmed = recording.getBaseName().substring(0, recording.getBaseName().length()-multiIdentifierLength);                                        
                    Logger.INFO("Base name for partial recording determined to be: " +baseNameTrimmed);
                                        
                    IOFileFilter multipartVideoSiblingFilter = getSiblingFilter(baseNameTrimmed, Config.VIDEO_EXTS, true);//true to use .startsWith() for baseName comparison                                        
                    List<File> partialRecordings =  new ArrayList<File>(FileUtils.listFiles(recordingFile.getParentFile(), multipartVideoSiblingFilter, null));
                    if(partialRecordings.isEmpty())
                    {
                        Logger.ERROR("Found no recordings using baseName: "+ baseNameTrimmed+". Cannot continue.");
                        continue toNextRecording;
                    }
                    Logger.DEBUG("Found partial recording(s): " +partialRecordings);                    
                    
                    boolean isSingleRecording = partialRecordings.size() == 1;                    
                    if(!isSingleRecording && !canBeSpreadAcrossMulipleFiles)
                    {//multipe parts but joining is not allowed. We can't do anything here                                                
                        Logger.WARN("Partial recording found (distributed over "+partialRecordings.size()
                                +" files) and JOIN_PARTIAL_RECORDINGS/DB_ADD_PARTIAL_EPISODES are disabled."
                                +LINE_BRK+ "Will not attempt to process: "+ recordingFile.getAbsolutePath());                    
                        continue toNextRecording;                        
                    }
                                                                                                                                                                
                    Collections.sort(partialRecordings, oldestFileFirst);                    
                    Logger.INFO("Partial recordings (sorted) are:");
                    for(File f : partialRecordings)
                        Logger.INFO("  "+ f.getAbsolutePath());
                    
                    //only process the last file to avoid double-processing or processing too early
                    boolean isLastFile = isSingleRecording || recordingFile.equals(partialRecordings.get(partialRecordings.size()-1));                                                                               
                    if(!isLastFile)
                    {                        
                        Logger.log(canBeSpreadAcrossMulipleFiles ? BTVLogLevel.NOTICE  : BTVLogLevel.WARN, 
                                "This isn't the last file in the recording parts, skipping it. "
                                + (canBeSpreadAcrossMulipleFiles ? "When last file is processed, will process all partial recordings." 
                                :"This program will not attempt to process this multi-file recording since both "
                                + "JOIN_PARTIAL_RECORDINGS and DB_ADD_PARTIAL_EPISODES is false.")
                        ,null);
                        continue toNextRecording;
                    }
                    Logger.INFO("This file is the last part of the recording(s).");
                    
                    int secToWait = TESTING ? 3 : Config.WAIT_FOR_MORE_PARTS_SEC;
                    Logger.INFO("Will wait "+secToWait+" seconds to see if more parts of this recording are created before processing.");
                    Thread.sleep(secToWait * 1000);
                    
                    List<File> partialRecordingsAfterWaiting = new ArrayList<File>(FileUtils.listFiles(recordingFile.getParentFile(), multipartVideoSiblingFilter, null));
                    Collections.sort(partialRecordingsAfterWaiting, oldestFileFirst);//sort so we can perform equals comparison on them
                    
                    //check if we found the same recordings before and after waiting
                    if(partialRecordingsAfterWaiting.equals(partialRecordings))
                    {//found same recordings after waiting for new ones
                        Logger.INFO("No more partial recording have been found and this recording is the last part, will process now.");
                    }
                    else if(partialRecordingsAfterWaiting.size() < partialRecordings.size())
                    {
                        Logger.ERROR("Unexpected: found less recordings after waiting "+secToWait+" sec: "
                                + "before="+partialRecordings.size()+", "
                                + "after="+partialRecordingsAfterWaiting.size()+LINE_BRK+
                                "Will not process this recording.");
                        continue toNextRecording;
                    }
                    else if(partialRecordingsAfterWaiting.size() > partialRecordings.size())
                    {
                        Logger.INFO("More partial recording(s) have been found (now "+ partialRecordingsAfterWaiting.size()
                                +" recordings, previously "+ partialRecordings.size()+"). "
                                + "Ending now. Will check again when new recording(s) finish.");
                        continue toNextRecording;
                    }
                    else
                    {
                        Logger.ERROR("Found same number of recordings before and after, but lists are not equal. Unexpected!");
                        continue toNextRecording;
                    }
                    
                    //if got here, this is the last recording and it can be processed                    
                    if(!isSingleRecording)
                    {//need to join recordings
                        
                        if(recording.isMovie())
                        {
                            //XBMC allows movies can be spread across multiple files
                            //move all the movies dir using XBMC naming conventions of .part1 ... partN
                            int part=1;
                            for(File moviePartFile : partialRecordings)
                            {
                                Recording nextPart = getRecording(moviePartFile);
                                if(nextPart == null)
                                {
                                    Logger.ERROR("Cannot archive multi-part movie because failed to get meta data on it: "+ nextPart);
                                    continue toNextRecording;
                                }
                             
                                //determine name using partN                                
                                String newLocation = determineNewLocation(nextPart,false,"part"+(part)+"."+nextPart.getExtension(),"");                                                                
                                Logger.INFO("Part "+ part +" ("+nextPart.getRecordingFile().getName()+") will be archived at: "+ newLocation);
                                moveToNewLocation(nextPart, newLocation);
                                part++;
                            }
                            Logger.NOTICE("Successfully archived multi-file movie: "+ recording.getMovieTitle());
                            continue toNextRecording;//nothing else to do since we've already called moveToArchivedLocation() for all the parts                            
                        }
                        else//XBMC needs TV Episodes to be a single file :( http://forum.xbmc.org/showthread.php?tid=82040
                        {                                                                                    
                            if(Config.JOIN_PARTIAL_RECORDINGS)
                            {
                                boolean joined = joinPartialRecordings(partialRecordings, recording, baseNameTrimmed);
                                if(joined)                                                    
                                {
                                    Logger.NOTICE("Successfully joined "+ partialRecordings.size() +" recordings. "+LINE_BRK
                                            + "Will end now and let FTR processing command(s) handle archiving the newly joined recording.");
                                    continue toNextRecording;
                                }
                                else
                                {                                    
                                    Logger.WARN("Failed to join recordings using FFMPEG.");
                                    //dont end yet, need to check if DB_ADD_PARTIAL_EPISODES is true
                                }
                            }
                            
                            if(Config.DB_ADD_PARTIAL_EPISODES)
                            {
                                //Either joining was unsuccessful or it was disabled.
                                Logger.NOTICE("Will attempt to add multi-file recording to XBMC database using "+STACK_PREFIX+" protocol.");
                                int part=1, seasonZeroEpisodeNumberToUse = -1;
                                List<String> newXBMCLocations = new ArrayList<String>();                            
                                for(File episodePartFile : partialRecordings)
                                {
                                    Recording nextPart = getRecording(episodePartFile);
                                    if(nextPart == null)
                                    {
                                        Logger.ERROR("Cannot archive multi-part episode because failed to get meta data on it: "+ nextPart);
                                        continue toNextRecording;
                                    }                                                             

                                    boolean isFirstPart = part == 1;
                                    boolean isSpecial = !nextPart.seasonEpisodeAvailable();//should be the same for all episodes
                                    if(isSpecial)
                                    {                                    
                                        //for specials we want all episodes to use the same next available season zero number
                                        nextPart.setSpecial(seasonZeroEpisodeNumberToUse);
                                    }

                                    //determine new location
                                    boolean createNfos = isFirstPart;
                                    String extToUse = nextPart.getExtension();
                                    String extraNameIdentifier = ".part"+(part);
                                    if(!isFirstPart)//hide all but the first one so we dont get dups in the library
                                        extraNameIdentifier += XBMC_EXCLUDE_TV_SHOW_IDENTIFIER;                                    
                                    String newLocation = determineNewLocation(nextPart,createNfos,extToUse,extraNameIdentifier);  
                                    
                                    if(isSpecial && isFirstPart)
                                    {   
                                        //use the episode number found from the first archived recording for all special archivings                                                                                 
                                        seasonZeroEpisodeNumberToUse = nextPart.getEpisodeNumber();//one-time initialization. EpisodeNumber was set in determineNewLocation() above                                    
                                    }

                                    newXBMCLocations.add(convertUNCToXBMCSmb(newLocation));//track these so we can build a .strm from them
                                    Logger.INFO("Part "+ part +" ("+nextPart.getRecordingFile().getName()+") will be archived at: "+ newLocation);
                                    moveToNewLocation(nextPart, newLocation);
                                    part++;
                                }

                                //if we got here, all the parts have been moved. Now track the files so we can manually update XBMC's library later
                                if(!recording.seasonEpisodeAvailable())
                                    recording.setSpecial(seasonZeroEpisodeNumberToUse);//use same special for nfo number as parts

                                //Add to tracking file (to-do list)
                                addFutureDBMultiFileEpisode(newXBMCLocations);
                                
                                Logger.NOTICE("Successfully added to to-do list. When this episode is added to XBMC's library, "
                                        + "will update the XBMC database to point to the "+ partialRecordings.size() +" recorded files that represent this episode.");
                                continue toNextRecording;//Success. Nothing else to do since we've already called moveToArchivedLocation() for all the parts
                            }
                            else//cant process this
                            {
                                Logger.ERROR("Cannot process this multi-file episode. "
                                        + "Configuration is: JOIN_PARTIAL_RECORDINGS="+Config.JOIN_PARTIAL_RECORDINGS+", DB_ADD_PARTIAL_EPISODES="+Config.DB_ADD_PARTIAL_EPISODES);
                                continue toNextRecording;
                            }
                        }
                    }
                    else
                    {//single recording, nothing to join. Just process normally since its a single file
                        Logger.INFO("Processing single-file partial recording: "+ recordingFile);
                    }                                                                                
                }//end if partial recording
                                
                
                //determine the dropbox location for this recording
                String newRecordingLocation = null;
                if(recording.isMovie())
                {                    
                    Logger.INFO("Movie found, will archive in "+ MOVIES_DIR +" directory: "+ recordingFile);                                                                              
                }                                
                
                //Will automatically handle manual/season 0 recordings
                newRecordingLocation = determineNewLocation(recording);                                                                                                              

                //Now move it (also update FTR with new location)
                moveToNewLocation(recording, newRecordingLocation);
                
                 
            }
            catch(Exception x)
            {
                Logger.ERROR("General error processing recording: "+ recordingFile.getAbsolutePath(),x);      
                continue toNextRecording;
            }
        }//end looping thru recording(s) to process               
        
        
        
        int waitSecForMultiFileIntegration = 0;
        if(filesWereArchived)
        {
            //XBMC Video Library update trigger
            if(Config.XBMC_LIBRARY_UPDATE)
            {
                triggerXBMCUpdate();
                waitSecForMultiFileIntegration = Config.XBMC_LIBRARY_UPDATE_SEC;//give xbmc time to update                
            }
            else
                Logger.DEBUG("Not triggering XBMC library update because XBMC_LIBRARY_UPDATE = "+ Config.XBMC_LIBRARY_UPDATE);
           
        }
        else
            Logger.WARN("No recordings were archived.");    
        
        //check the multi-file todo's
        try
        {
            Map<String,String> multiFileTodos = getDbPartialEpisodeToDoList();
            if(multiFileTodos != null && !multiFileTodos.isEmpty())
            {
                if(waitSecForMultiFileIntegration > 0)
                {                    
                    Logger.INFO("Will wait "+ (waitSecForMultiFileIntegration) +" seconds before processing "+ multiFileTodos.size() +" multi-file XBMC DB edits...");
                    Thread.sleep(waitSecForMultiFileIntegration*1000);                    
                }                
                integrateMultiFileEdits(multiFileTodos);
            }
            else
                Logger.DEBUG("No multi-file XBMC DB todo's found. Skipping.");            
        }
        catch(Exception x)
        {
            Logger.ERROR("Failed to process multi-file todo list: "+x,x);
        }
    }
    
    //Helpers:
    
    /**
     * Attempts to integrate all the multi-file todo's in the <code>multiFileTodos</code> Map with XBMC's database.<br>
     * The 'key' file is the first file in the stack and also the Key in the Map that is passed in.
     * If the key file does not exist in XBMC's database, the entry will be skipped and remain in the list.
     * Also checks the list for missing key files (get removed from list)
     * @param multiFileTodos A map whose key is is the first file in the stack:// (the 'key' file) and value is the stack itself.
     * @throws Exception if there is any problem integrating with XBMC's db
     */
    private void integrateMultiFileEdits(Map<String,String> multiFileTodos) throws Exception
    {        
        if(multiFileTodos==null || multiFileTodos.isEmpty())
        {
            Logger.INFO("Nothing in multi-file todo list... Skipping multi-file db edits");
            return;
        }
        
        Logger.INFO("Will now process "+ multiFileTodos.size() +" multi-file XBMC DB edits "
                + "(if the corresponding episodes are found in XMBC's database)");        
        
        int successCount = 0, missingCount = 0;
        XBMCVideoDbInterface xbmcDb = null;
        try
        {
            File dbConfigFile = new File(BASE_DIR, "XbmcDb.properties");
            XBMCDbConfig dbConfig = new XBMCDbConfig(dbConfigFile);        
            xbmcDb = new XBMCVideoDbInterface(new XBMCVideoDb(dbConfig));

            if(!xbmcDb.isConnected())
                throw new Exception("Failed to connect to XBMC video database. Check you config file: "+dbConfigFile);

            
            for(Iterator<Map.Entry<String,String>> it = multiFileTodos.entrySet().iterator(); it.hasNext();)
            {
                Map.Entry<String,String> entry = it.next();
                String xbmcFileToLookFor = entry.getKey();
                //make sure this file still exists on disk...
                String uncPath = convertSMBToUnc(xbmcFileToLookFor);
                File uncFile = new File(uncPath);
                if(!uncFile.exists())
                {
                    Logger.NOTICE("This video no longer exists. Removing it from todo list: " + uncPath + "("+xbmcFileToLookFor+")");
                    it.remove();
                    missingCount++;
                    continue;
                }
                String stackedFiles = entry.getValue();

                int fileId = xbmcDb.getFileId(xbmcFileToLookFor);
                if(fileId > 0)
                {
                    Logger.INFO("Found file (id="+fileId+") in XBMC's database, now check if it is in TV Shows library.");
                    int episodeId = xbmcDb.getVideoIdInLibrary(VideoType.TV_SHOW, fileId);
                    if(episodeId > 0)
                    {
                        Logger.INFO("Found episode in XBMC's TV Show library, id="+episodeId);
                        Logger.INFO("Updating episode to point to multi files instead of single file. New location will be: "+ stackedFiles);

                        //only need to update the file reference. Path can stay the same
                        Logger.INFO("Changing file pointer for fileId "+ fileId+LINE_BRK+
                                "From: "+ xbmcFileToLookFor +LINE_BRK+
                                "To:   " + stackedFiles);

                        boolean success = xbmcDb.updateFilePointer(fileId, stackedFiles);
                        if(!success)
                            Logger.ERROR("Failed to update XBMC Db pointer. Will try again next time.");
                        else
                        {
                            Logger.NOTICE("Successfully updated XBMC Episode file pointer to use multi-file stack protocol for episodes: "+ stackedFiles);
                            successCount++;
                            it.remove();//remove from list                            
                        }
                    }
                    else
                        Logger.INFO("No episode in XBMC's database tied to this file ID yet, will check again next time.");                    
                }//end if fileid is available
                else
                    Logger.INFO("No file id found in XBMC's database for file. Will try again next time: "+ xbmcFileToLookFor);

            }//end looping through todos                        
        }
        catch(Exception x)
        {
            Logger.ERROR("Error processing multi-file XBMC db edits: "+x,x);
        }
        finally
        {
            if(xbmcDb != null)
                xbmcDb.close();
        }
        
        if(successCount > 0)
        {
            Logger.INFO("Updating to-do list. Removing "+ successCount +" successes and "+missingCount+" deleted files from the list.");
            //if any were successful, overwrite the list with the remaining ones (successes have already been removed from multiFileTodos)
            FileUtils.writeLines(dbMultiToDoFile, multiFileTodos.values(), false);      
            Logger.INFO("The to-do list now has "+ multiFileTodos.size() +" entries in it.");            
        }        
    }
    
    /**
     * Gets a Map of pending multi-file episodes that are waiting to be added to XBMC's database.
     * @return Map with key being the first file in the multi-file stack and the value being the stack itself.
     * @throws Exception if there is a problem getting the list.
     */
    private Map<String,String> getDbPartialEpisodeToDoList() throws Exception
    {
        Map<String,String> todos = new LinkedHashMap<String,String>();//key is first file, value is all files in stack:// protocol
                
        if(!dbMultiToDoFile.isFile()) return null;
        List<String> lines = FileUtils.readLines(dbMultiToDoFile);
        for(String stack : lines)
        {
            try
            {
                List<String> xbmcFileLocations = getStackAsList(stack);
                if(xbmcFileLocations.isEmpty()) throw new Exception("No files parsed from stack: "+ stack);
                String keyFile = xbmcFileLocations.get(0);
                todos.put(keyFile, stack);
            }
            catch(Exception x)
            {
                Logger.WARN("Unable to process to-do item (not valid stack): "+ stack);
                continue;
            }
        }
        Logger.INFO("Found "+ todos.size() +" multi-file entries that need to be updated in XBMC's database.");
        return todos;
    }
    
    /**
     * Adds a new entry to the <code>dbMultiToDoFile</code> File.<br>
     * A line with a stack:// of all filePaths will be added.
     * @param xbmcFileLocations All file paths (already in XBMC format) that make up the multi-file episode
     * @throws Exception 
     */
    private void addFutureDBMultiFileEpisode(List<String> xbmcFileLocations) throws Exception
    {
        
        if(!dbMultiToDoFile.isFile())
        {
            Logger.NOTICE("Creating XBMC Db multi-file to-do list at: "+ dbMultiToDoFile);
            if(!dbMultiToDoFile.createNewFile())
                throw new Exception("Failed to create file at: "+ dbMultiToDoFile);
        }                
        
        String keyFile = xbmcFileLocations.get(0);//the file we will look for in XBMC's db and update to a stack:// for all files
        
        //check if we already have this as a todo:
        String newTodo = convertToXBMCStackProtocol(xbmcFileLocations, false);
        Logger.INFO("Adding new entry to Db MultiFile list: "+ newTodo);
        List<String> currentToDos = FileUtils.readLines(dbMultiToDoFile);
        List<String> newTodoList = new ArrayList<String>();
        boolean replaced = false;
        for(String existingTodo:currentToDos)
        {
            if(!valid(existingTodo))
                continue;
            
            List<String> existingTodoList = getStackAsList(existingTodo);
            if(existingTodoList.get(0).equals(keyFile))
            {
                Logger.WARN("This files is already in todo list. Replacing"+LINE_BRK +
                        "Orig: "+ existingTodo+LINE_BRK+
                        "New:  "+ newTodo);
                newTodoList.add(newTodo);
                replaced = true;
            }
            else
            {
                newTodoList.add(existingTodo);//no change
            }
        }
        if(!replaced)
            newTodoList.add(newTodo);
        
        Logger.INFO("Updating todo list now (prev size = "+ currentToDos.size()+", new size = "+ newTodoList.size()+"): "+dbMultiToDoFile);
        FileUtils.writeLines(dbMultiToDoFile, newTodoList, false);//over-write with new lines
        //done
    }
    
    
    /**
     * Converts XBMC stack:// protocol to a String list of the file paths that make up the stack
     * @param stack
     * @return List of paths in the stack
     */ 
    private List<String> getStackAsList(String stack)
    {        
        if(!valid(stack) || !stack.toLowerCase().startsWith(STACK_PREFIX))
            throw new IllegalArgumentException("Invalid stack protocol, does not start with \""+STACK_PREFIX+"\": "+ stack);
        
        stack = stack.substring(STACK_PREFIX.length());
        if(!stack.contains(STACK_SEP))
        {
            Logger.WARN("Found stack that does not contain any seperators \""+STACK_SEP+"\". Returning single file.");
            return toArrayList(stack);
        }
        else
        {
            String[] stackParts = stack.split(STACK_SEP);
            //Logger.DEBUG("Found "+ stackParts.length +" stacked files: "+ Arrays.toString(stackParts));
            return new ArrayList<String>(Arrays.asList(stackParts));
        }
    }
    
    /**
     * Converts a list of String paths to a single XBMC stack:// protocol file. 
     * The files in the list are not converted unless pathsAreUnc is true, in which case they are converted
     * from UNC to XBMC SMB.
     * @param filePaths List of XBMC file paths to add to the stack://
     * @param pathsAreUnc true if the paths in filePaths are UNC (\\server\\share...). They will be converted to smb://<br>
     * If false, no conversion is done to the paths.
     * @return a stack:// XBMC string for all files in the list
     */
    private String convertToXBMCStackProtocol(List<String> filePaths, boolean pathsAreUnc)
    {
        //Example:
        //stack://smb://ONYX/Data-2tb/Videos/DVDs/500 Days of Summer (2009).part1.avi , smb://ONYX/Data-2tb/Videos/DVDs/500 Days of Summer (2009).part2.avi , smb://ONYX/Data-2tb/Videos/DVDs/500 Days of Summer (2009).part3.avi
        
        String stack = STACK_PREFIX;
        for(Iterator<String> it = filePaths.iterator(); it.hasNext();)
        {
            
            String smbPath = it.next(); 
            if(pathsAreUnc)
                smbPath = convertUNCToXBMCSmb(smbPath);
            //else no conversion
            
            stack += smbPath;
            if(it.hasNext())
                stack += STACK_SEP;
        }
        return stack;
    }
    
    /**
     * Gets a Recording from FTR webservice using the full UNC path to the recording.
     * @param recordingFile Full UNC path to recording file     
     * @return The recording if it is found, otherwise null
     */
    private Recording getRecording(File recordingFile)
    {
        try
        {                                            
            String jsonPath = new JsonPrimitive(recordingFile.getAbsolutePath()).toString();                
            String recordingJson = BTVHTTP.getResponseAsString(
                    HTTP.POST(Config.FTR_WEBSERVICE_URL+"/Control/RecordingByFileName", 
                    jsonPath, 
                    BTVHTTP.createHeaders("Content-Type", "application/json"))
            );
            if(!valid(recordingJson)) 
                throw new Exception("Nothing returned from webservice.");
            Recording recording = gson.fromJson(recordingJson, Recording.class);
            recording.init(recordingFile);
            return recording;
        }
        catch(Exception x)
        {
            Logger.ERROR("Failed to get Recording information from FTR webservice for recording: "+ recordingFile,x);
            return null;
        }
    }
    
    /**
     * <code>createNfos</code> defaults to true<br>
     * <code>extensionToUse</code> defaults to {@link Recording#getExtension()}<br>
     * <code>extraNameIdentifier</code> defaults to empty String<br>         
     * @see ForTheLibrary#determineNewLocation(Recording, boolean, String, String) 
     */
    private String determineNewLocation(Recording recording) throws Exception
    {
        return determineNewLocation(recording, true, recording.getExtension(), "");//defaults
    }
    /**
     * Determines the new location of the recording in either the TV_SHOWS or MOVIES directory.<br>
     * Archives TV Shows in format: <code>Series (yearIfAvail)/Season X/SxxExx - Title extraNameIdentifier [hash]extraNameIdentifier.ext</code><br>
     * Archives Movies in format: <code>MovieTitle (yearIfAvail) extraNameIdentifier [hash].ext</code><br>
     * Also creates .nfo's with TVDB SeriesID (if available) for knows episodes and will create .nfo's to manually
     * archive unknown episodes using meta-data from FTR.<br>
     * A CRC hash of the FTR RecordingID will be added to all locations in order to guarantee they are unique.
     * @param recording The FTR recording to determine the new location for. It should have all meta-data already added to it.
     * @param createNfos set to true to enable creating .nfo files. false will disable creation of all .nfo's for this Recording
     * @param extensionToUse the extension (not including the .) to use for the video
     * @param extraNameIdentifier Gets added to the end of the new location (before hash)
     * @return String of the new location that the Recording should be archived at
     * @throws Exception If the new location can not be determined for any reason.
     */
    private String determineNewLocation(Recording recording, boolean createNfos, String extensionToUse, String extraNameIdentifier) throws Exception
    {
        String idHash = XbmcCRC.toCRC(recording.getRecordingId());
        if(!valid(idHash))        
            Logger.WARN("Could not generature unique hash from recording id: "+ recording.getRecordingId());
        else
            extraNameIdentifier += " ["+idHash+"]";//uniquely identify based on recording id to avoid FTR conflicts if we record same episode more than once
        
        if(valid(extraNameIdentifier))
            extraNameIdentifier = safeFileName(extraNameIdentifier);
        
        String recordingShareFolder = getParentRecordingShare(recording.getRecordingFile());
        if(recording.isMovie())
        {
            //use same parent directory that we are scanning
            return recordingShareFolder +SEP+

            //Movie Directory, static for movies
            MOVIES_DIR + SEP +

            //The movie title, according to the guide data
            safeFileName(recording.getMovieTitle())+

            //Append the year if it's available
            (recording.isYearAvailable() ? " ("+recording.getYear()+")" : "") +

            //Add the extension
            extraNameIdentifier+"."+extensionToUse;      
        }
        else//tv show
        {
            if(recording.seasonEpisodeAvailable())
            {
                //use same parent directory that we are scanning
                return recordingShareFolder +SEP +

                //TV show Directory, static for all shows
                TV_SHOWS_DIR + SEP +

                //Series directory. Name of series plus year if available
                safeFileName(recording.getSeriesName()) +

                //Append year if it's available
                (recording.isYearAvailable() ? " ("+recording.getYear()+")" : "") +SEP +

                //Season directory.  "Season [seasonNumber]"
                Config.SEASON_WORD +" " + recording.getSeasonNumber() +SEP +

                //Name of the file is SxxExx - episodeTitle.ext
                recording.getSeasonEpisodeNaming() +" - "+ safeFileName(recording.getEpisodeTitle())+

                //Add the extension
                extraNameIdentifier + "." + extensionToUse;
            }
            
            else//manual archiving (Season 0)
            {
                if(!valid(recording.getSeriesName()))
                    throw new Exception("Cannot manually archive because no series name is available.");

                Logger.INFO("Will manually archive this recording as a "+Config.SEASON_WORD+" 0 (special) using .nfo files.");

                //check if the Series folder exists - create it if not
                File seriesDir = new File( 
                    
                    //use same parent directory that we are scanning
                    recordingShareFolder +SEP +
                        
                    //TV show Directory, static for all shows
                    TV_SHOWS_DIR + SEP +

                    //Series directory is name of series plus year if available
                    safeFileName(recording.getSeriesName()) +

                    //Append year if it's available
                    (recording.isYearAvailable() ? " ("+recording.getYear()+")" : "")
                );
                
                if(!seriesDir.isDirectory())
                {
                    Logger.INFO("Creating Series Directory at: "+ seriesDir);
                    FileUtils.forceMkdir(seriesDir);//throws exception on fail
                }

                //check for series NFO and create if doesnt exist
                if(createNfos)
                {
                    File seriesNfo = new File(seriesDir, "tvshow.nfo");
                    if(!seriesNfo.isFile())
                    {
                        //check if there are already season 1 or greater folders, indicating that XBMC                         
                        //already has scraped this TV Show's info and we don't need to manually specify it in a tvshow.info
                        boolean alreadyArchivedValidEpisodes = false;
                        String[] seasonDirs = seriesDir.list(new PrefixFileFilter(Config.SEASON_WORD));
                        for(String seasonDir : seasonDirs)
                        {
                            if(!seasonDir.equals(Config.SEASON_WORD +" 0"))
                            {
                                Logger.INFO("Found \""+ seasonDir +"\" directory, so not creating a manual tvshow.nfo "
                                        + "since it appears this show has been successfully scraped for non-"+Config.SEASON_WORD+" 0 episodes.");
                                alreadyArchivedValidEpisodes = true;
                                break;
                            }
                        }

                        if(!alreadyArchivedValidEpisodes)
                        {
                            Logger.INFO("Creating tvshow.nfo at: "+ seriesNfo.getAbsolutePath());
                            String tvshowNfoContents = getTVShowNfo(recording);                            
                            FileUtils.write(seriesNfo, tvshowNfoContents);                        
                            Logger.INFO("Series nfo file successfully created at: "+ seriesNfo);
                        }                        
                    }//end creating series nfo
                    else
                        Logger.INFO("tvshow.nfo already exists for this series. Will not overwrite it");
                }
                

                //check for season zero dir
                File seasonZeroDir = new File(seriesDir, Config.SEASON_WORD+" 0");
                if(!seasonZeroDir.exists())
                {
                    if(!seasonZeroDir.mkdir())
                    {
                        throw new Exception("Failed to create Season 0 directory for manual archiving at: "+ seasonZeroDir.getAbsolutePath());
                    }
                }

                //determine what the next episode number available is
                int nextEpisodeNumber;
                if(recording.getEpisodeNumber() > 0)//it has been over-ridden. Use this value
                    nextEpisodeNumber = recording.getEpisodeNumber();
                else
                    nextEpisodeNumber = getNextAvailableEpisodeNumber(seasonZeroDir);        
                
                if(nextEpisodeNumber == -1)
                    throw new Exception("Unable to determine next special episode number.");                    
                
                recording.setSpecial(nextEpisodeNumber);//also sets season to zero


                //determine the destination for this recording
                if(!recording.hasEpisodeTitle())
                {
                    //no episode title, use aired date if avail
                    if(valid(recording.getAiredDate()))
                        recording.setEpisodeTitle(recording.getAiredDate());
                    else
                    {
                        Logger.WARN("This recording does not appear to have a valid episode title or air date!");                            
                        recording.setEpisodeTitle("FTR Recording");//should never get here since FTR alwasy adds aired date
                    }
                }

                String destinationNoExt = 
                        seasonZeroDir.getAbsolutePath()+SEP//Season 0 dir
                        + "S"+padInt(recording.getSeasonNumber())+"E"+padInt(recording.getEpisodeNumber())//S00EXX 
                        +" - "+ safeFileName(recording.getEpisodeTitle());//- Title                    

                destinationNoExt += extraNameIdentifier;
                String newRecordingLocation = destinationNoExt +"."+ extensionToUse;

                if(createNfos)
                {
                    File episodeNfo = new File(destinationNoExt+".nfo");
                    if(episodeNfo.isFile())
                    {
                        Logger.WARN(Config.SEASON_WORD+ " 0 .nfo file already found for this recording. Will overwrite: "+ episodeNfo);
                        if(!episodeNfo.delete())
                            throw new Exception("Failed to overwrite nfo file at: "+ episodeNfo);
                    }
                    
                    //use available 4tr meta data to create an xbmc .nfo file
                    String episodeNfoContents = getEpisodeNfo(recording);

                    //create the nfo file
                    Logger.INFO("Creating episode .nfo at: "+ episodeNfo);
                    FileUtils.write(episodeNfo, episodeNfoContents);  
                }                                 
                return newRecordingLocation;
                
            }
        }
    }
    
    /**
     * Moves recording and all of its siblings to the new location.<br>
     * Also deletes any artifacts based on {@link Config#ARTIFACT_DELETE} and {@link Config#ARTIFACT_EXTS}<br>
     * Also creates a tvshow.nfo with the TVDB SeriesId in it if the SeriesId is available and the tvshow.nfo does not already exist<br>
     * If the recording is successfully moved, will call {@link ForTheLibrary#updateFTRWithNewRecordingLoc(String, String)} to update FTR with new location.
     * @param recording The recording to move.
     * @param newRecordingLocation The new location of the reocrding video file     
     * @throws Exception If there is a problem moving the recording
     */
    private void moveToNewLocation(Recording recording, String newRecordingLocation) throws Exception
    {
        //move to new location if it was able to be determined
        if(valid(newRecordingLocation))
        {
            Logger.NOTICE("New location will be: "+newRecordingLocation);
            File newVideoFile = new File(newRecordingLocation);
            if(newVideoFile.exists())
            {
                throw new Exception("This file is already archived"
                        +LINE_BRK+"Location = "+ newRecordingLocation
                        +LINE_BRK+"Recording = "+ recording.getRecordingFile().getAbsolutePath());
            }

            File newLocParentDir = newVideoFile.getParentFile();
            if(!newLocParentDir.exists())
            {
                try
                {
                    Logger.INFO("Creating directory at: "+ newLocParentDir);
                    FileUtils.forceMkdir(newLocParentDir);
                }
                catch(Exception x)
                {   
                    throw new Exception( "Failed to create directory at: "+ newLocParentDir.getAbsolutePath() +LINE_BRK + x);                                               
                }
            }

            //create a tvshow.nfo with the TVDB id if it's available (helps XBMC know exactly what series this is)
            if(!recording.isMovie())
            {
                if(!recording.isManuallyArchived())//manually archived recordings already took care of tvshow.fno
                {
                    File seriesDir = newLocParentDir.getParentFile();
                    File tvShow_nfo = new File(seriesDir, "tvshow.nfo");
                    if(recording.hasTVDBId())//we need this to create the nfo
                    {//only create the .nfo if we know the tvdb id
                        if(!tvShow_nfo.exists())//if it was already created, then we can skip creating it
                        {                                                        
                            try
                            {
                                FileUtils.writeStringToFile(tvShow_nfo, 
                                    "http://thetvdb.com/index.php?tab=series&id="+recording.getTVDBId());
                                Logger.INFO("Created tvshow.nfo for TVDB Series ID: "+ recording.getTVDBId()
                                    +" at: "+ tvShow_nfo.getAbsolutePath());
                            }
                            catch(Exception x)
                            {
                                Logger.ERROR("Failed to create tvshow.nfo with TVDV SeriesId in it at: "+ tvShow_nfo.getAbsolutePath());                                
                            }                            
                        }
                        else
                        {
                            Logger.INFO("tvshow.nfo already exists, will not overwrite: "+ tvShow_nfo);
                        }
                    }
                }
            }

            //delete any artifacts that we don't want to move
            if(Config.ARTIFACT_DELETE)
            {
                Logger.INFO("Deleting artifacts with extension(s): "+ Config.ARTIFACT_EXTS);

                Collection<File> artifacts = FileUtils.listFiles(recording.getRecordingFile().getParentFile(), getSiblingFilter(recording.getBaseName(), Config.ARTIFACT_EXTS),null);
                Logger.INFO("Found "+ artifacts.size() +" artifacts to delete");

                for(File artifact : artifacts)
                {                                                                                                           
                    if(artifact.delete())
                        Logger.INFO("Deleted artifact: "+ artifact.getAbsolutePath());
                    else
                        Logger.WARN("Failed to delete artifact at: "+ artifact.getAbsolutePath());                           
                }
            }//done with artifact delete


            //move recording and all meta-files to new location                    
            Collection<File> recordingAndSiblings = FileUtils.listFiles(recording.getRecordingFile().getParentFile(), getSiblingFilter(recording.getBaseName(), null), null);
            Logger.INFO("Found "+ recordingAndSiblings.size() + " files to archive...");
            Logger.DEBUG("Recording & siblings to move: "+ new ArrayList<File>(recordingAndSiblings));

            boolean recordingMoved = false;
            movingFiles:for(File fileToMove : recordingAndSiblings)
            {

                boolean isRecording = fileToMove.equals(recording.getRecordingFile());
                String ext = FilenameUtils.getExtension(fileToMove.getName());

                if(Config.EDL_CONVERSION)
                {
                    boolean isEdl = valid(ext) && ext.equalsIgnoreCase("edl");
                    if(isEdl)
                    {
                        Logger.INFO("Found .edl file. Will convert to type 3 before moving...");
                        convertEdl(fileToMove);
                    }
                }//end edl conversion

                File dest = new File(newLocParentDir, FilenameUtils.removeExtension(newVideoFile.getName())+"."+ext);
                if(dest.exists())
                {
                    BTVLogLevel severity = isRecording ? BTVLogLevel.ERROR : BTVLogLevel.WARN;
                    Logger.log(severity, "Destination file already exists, will not overwrite."
                            + LINE_BRK+"Source:      "+fileToMove.getAbsolutePath()
                            + LINE_BRK+"Destination: "+ dest.getAbsolutePath(), null);

                    continue movingFiles;
                }

                final int max_tries = 3;
                for(int tries =1; tries <= max_tries; tries++)
                {
                    boolean lastTry = tries == max_tries;
                    try
                    {
                        FileUtils.moveFile(fileToMove, dest);
                        if(isRecording){
                            recordingMoved = true;
                            archivedRecordings.add(dest);
                        }
                        Logger.INFO("Successfully moved " +(isRecording ? "recording" : "file")+" to new location: "+ dest);
                        break;
                    }
                    catch(Exception moveFail)
                    {
                        BTVLogLevel severity = lastTry ? BTVLogLevel.ERROR : BTVLogLevel.WARN;
                        Logger.log(severity, "Attempt "+tries +" of " + max_tries +": "
                                + "Failed to move "+(isRecording ? "recording" : "file")+":"
                                + LINE_BRK+"Source:      "+fileToMove.getAbsolutePath()
                                + LINE_BRK+"Destination: "+ dest.getAbsolutePath()
                                ,moveFail);
                        
                        if(!lastTry)
                        {
                            int waitSec = tries * 60;
                            Logger.INFO("Will try to move again in " + waitSec +" seconds.");
                            Thread.sleep(waitSec);
                        }
                        else
                        {
                            Logger.ERROR("Failed to move after "+ tries +" tries. Will not try again.");
                        }
                    }
                }
            }        

            if(recordingMoved)
            {
                filesWereArchived = true;                        
                Logger.INFO("Recording has been moved to new location, now updating FTR with the new location");
                updateFTRWithNewRecordingLoc(recording.getOriginalPath(), newRecordingLocation);//throws error if fails
                
                if(valid(Config.POST_PROCESSING_BAT_FILE))
                {
                    //starts in background thread. doesn't block
                    Logger.INFO("Starting post-processing command: "+ Config.POST_PROCESSING_BAT_FILE +" for \""+newRecordingLocation+"\"");
                    PostProcessStarter ps = new PostProcessStarter(Config.POST_PROCESSING_BAT_FILE, newRecordingLocation);
                }
                else
                    Logger.DEBUG("Not executing a post-processing command because none specified in property: POST_PROCESSING_BAT_FILE");
            }
            else
                throw new Exception("The recording was not moved to the new location. Will not update FTR with new location.");
        }      
        else
            throw new Exception("Cannot move recording because no destination was specified for recording: "+ recording.getRecordingFile());
    }//end moveToNewLocation()
    
    /**
     * Updates FTR with new location of a Recording when its file is moved.
     * @param originalPath The old location of the recording file (full unc path)
     * @param newRecordingLocation The new location of the recording file (full unc path)
     * @throws Exception If there is an error updating FTR
     */
    private void updateFTRWithNewRecordingLoc(String originalPath, String newRecordingLocation) throws Exception
    {
        //use FTR Webserices to update recording pointer for the recording        
        
        String data = new JSONStringer()                                 
          .object()
            .key("RecordingFileName")
            .value(originalPath)//original recording location                                

            .key("NewRecordingFileName")
            .value(newRecordingLocation)//new recording location

            .key("NewRecordingStartTime")
            .value(null)//null means don't change

            .key("NewRecordingStopTime")
            .value(null)//null means don't change
          .endObject()                                        
        .toString();

        String url = Config.FTR_WEBSERVICE_URL+"/Control/ChangeRecordingFile";
        String response = BTVHTTP.getResponseAsString(HTTP.POST(url, data, BTVHTTP.createHeaders("Content-Type", "application/json")));
        if(response == null || !"true".equalsIgnoreCase(response.trim()))
        {
            throw new Exception("Failed to update FTR with new location of file."+LINE_BRK+
                    "POST URL = " + url+LINE_BRK+
                    "Data = "+ data + LINE_BRK +
                    "Response = "+ response);                            
        }

        Logger.NOTICE( "Successfully updated FTR."+LINE_BRK+
                "Old Location = "+ originalPath+LINE_BRK+
                "New location = "+ newRecordingLocation);
    }
    
    /**
     * Sort files based last modified date with oldest file first (the order they were created)
     */
    public static final Comparator<File> oldestFileFirst = new Comparator<File>()
    {
        @Override
        public int compare(File f1, File f2) 
        {
            long dateF1 = f1.lastModified();
            long dateF2 = f2.lastModified();                                               

            //older = higher in list
            if(dateF1 < dateF2) return -1;//F1 is older
            if(dateF2 < dateF1) return 1;//F2 is older
            else
            {
                Logger.ERROR("Found 2 files with exact same last modified date ("+new java.util.Date(dateF1)+"): "
                        + LINE_BRK+f1.getAbsolutePath()+LINE_BRK+f2.getAbsolutePath());
                return 0;
            }
        }
    };     

    /**
     * Finds the next available (unused) Season 0 number available in the specified directory
     * @param dirWithEpisodes Directory to scan for next available number
     * @return the next available number 
     */
    private int getNextAvailableEpisodeNumber(File dirWithEpisodes)
    {
         Set<Integer> usedEpisodeNumbers = new HashSet<Integer>();                                        
        Collection<File> seasonZeroRecordings = FileUtils.listFiles(dirWithEpisodes, Config.VIDEO_EXTS_ARRAY, false);
        for(File seasonZeroRecording : seasonZeroRecordings)
        {
            String fileName = seasonZeroRecording.getName();
            String SxxExx = getRegexMatch("S[0-9]+E[0-9]+", fileName);
            if(valid(SxxExx))
            {
                int eIndx = SxxExx.toUpperCase().indexOf("E");
                int thisEpisodeNumber = Integer.parseInt(SxxExx.substring(eIndx+1, SxxExx.length()));
                usedEpisodeNumbers.add(thisEpisodeNumber);                            
            }
            else
                Logger.WARN("Failed to parse SxxExx from recording: "+ fileName);
        }

        int nextEpisodeNumber = -1;
        for(int epNum=1;epNum<10000;epNum++)
        {
            if(!usedEpisodeNumbers.contains(epNum))
            {
                nextEpisodeNumber = epNum;
                break;//found one that isn't used
            }
        }        
        Logger.INFO("Determined next available special episode number to be: "+ nextEpisodeNumber);
        return nextEpisodeNumber;
    }
    
    /**
     * Return nfo XML for the episode in the specified Recording.
     * @param recording The Recording to generate the episode nfo for
     * @return XML String of episode nfo for the Recording
     */
    private String getEpisodeNfo(Recording recording)
    {
        Element root = new Element("episodedetails");
        Document episodeNfoXML = new Document(root);
        root.addContent(new Element("title").setText(recording.getEpisodeTitle()));
        if(valid(recording.getAiredDate()))
            root.addContent(new Element("aired").setText(recording.getAiredDate()));
        root.addContent(new Element("season").setText(recording.getSeasonNumber()+""));
        root.addContent(new Element("episode").setText(recording.getEpisodeNumber()+""));
        if(recording.hasDescription())
            root.addContent(new Element("plot").setText(recording.getDescription()));
        if(recording.hasRating())
            root.addContent(new Element("mpaa").setText(recording.getRating()));

        if(!recording.getActors().isEmpty())
        {
            /* XBMC Example for actors
                <actor>
                    <name>Robert Lee</name>
                    <role>USA Narrator</role>
                    <thumb></thumb>
                </actor>
                <actor>
                    <name>Adam Savage</name>
                    <role></role>
                    <thumb></thumb>
                </actor>
             */
            for(String strActor : recording.getActors())
            {
                Element nextActor = new Element("actor");
                Element name = new Element("name").setText(strActor);                
                nextActor.addContent(name);                 
                root.addContent(nextActor);                
            }
        }
        return getDocumentAsString(episodeNfoXML);
    }
    
    /**
     * Return tvshow.nfo XML string for the specified Recording
     * @param recording The Recording to generate tvshow.nfo for
     * @return String of tvshow.nfo XML
     */
    private String getTVShowNfo(Recording recording)
    {
        Element root = new Element("tvshow");
        Document tvshowNfoXML = new Document(root);
        root.addContent(new Element("title").setText(recording.getSeriesName()));//the series name                        
        if(recording.hasCategory())                        
            root.addContent(new Element("genre").setText(recording.getCategory()));
        if(recording.hasChannelDeisplayName())
            root.addContent(new Element("studio").setText(recording.getChannelDisplayName()));
        if(!recording.getActors().isEmpty())
        {
            for(String strActor : recording.getActors())
            {
                root.addContent(
                        new Element("actor")
                        .addContent(new Element("name").setText(strActor))
                        //we don't get role from ftr
                );
            }
        }
        return getDocumentAsString(tvshowNfoXML);
    }
    
    
    
    /**
     * Uses FTR webservice to delete a recording based on its UNC path
     * @param fullUNCRecordingPath The full UNC path of the recording.
     * @return true is successfully send delete command to FTR
     */
    private boolean deleteRecording(String fullUNCRecordingPath)
    {        
        
        String urlString = Config.FTR_WEBSERVICE_URL+"/Control/DeleteRecording?deleteRecordingFile=true";
        Logger.NOTICE("Deleting recording at: "+ fullUNCRecordingPath +" by calling: "+ urlString);
        try
        {
            //Post data is full unc path of recording as a json string
            
            String jsonPath = new JsonPrimitive(fullUNCRecordingPath).toString();
            
            String response = BTVHTTP.getResponseAsString(HTTP.POST(urlString, jsonPath,BTVHTTP.createHeaders("Content-Type", "application/json")));            
            if(response != null)
            {//FTR responds empty and status 200 for success.
                //NOTE it responses the same whether the file exists or not!
                Logger.INFO("Successfully triggered delete for "+fullUNCRecordingPath+" by calling: "+ urlString);
                return true;
            }
            else
                throw new Exception("No response from: "+ urlString);
            
        }catch(Exception x)
        {
            Logger.ERROR("Failed to delete recording: "+ fullUNCRecordingPath,x);
            return false;
        }
    }
    
    
    /**
     * Schedules a FTR processing command to be run at a specific time on a specific recording.
     * @param recordingId The id of the recording to run the command on
     * @param commandId The id of the processing command to run
     * @param runAtTime The time to run the command. If null will use current time.
     * @return true is successfully scheduled the command
     */
    private boolean runProcessingCommandOnRecording(String recordingId, String commandId, Date runAtTime)
    {
        //http://onyx:49943/ForTheRecord/Control/RunProcessingCommandOnRecording/{recordingId}/{processingCommandId}/{runAtTime}        
        if(runAtTime == null) runAtTime = new Date();//default now
        String formattedRunAtTime = SDF_FTR_ISO.format(runAtTime);//Like: 2011-03-01T15:13:26-06:00
        String urlString = Config.FTR_WEBSERVICE_URL+"/Control/RunProcessingCommandOnRecording/"
                + recordingId+"/"
                + commandId+"/"
                + formattedRunAtTime;        
        try
        {
            String response = BTVHTTP.getResponseAsString(HTTP.POST(urlString, null));                        
            if(response != null)//FTR response with empty string on success.                
                return true;
            else
                throw new Exception("No response found from FTR Server");
        }
        catch(Exception x)
        {
            Logger.ERROR("Failed to trigger processing command using: "+ urlString,x);
            return false;
        }        
    }
    
    final int//4tr mappings for processing command types
            PROC_COMMAND_RECORDING_START =      0,//when recording starts             
            PROC_COMMAND_RECORDING_END =        1,//when recording ends (no delay)
            PROC_COMMAND_FIXED_TIME_AFTER_END = 2,//when recording ends, delayed by x hours and x minutes
            PROC_COMMAND_BEFORE_DELETE =        3;//right before recording is deleted
    /**
     * Gets list of processing commands from FTR webservice. Filtered by typesToGet
     * @param typesToGet The types of commands to return. Options are {@link ForTheLibrary#PROC_COMMAND_BEFORE_DELETE},
     * {@link ForTheLibrary#PROC_COMMAND_FIXED_TIME_AFTER_END}, {@link ForTheLibrary#PROC_COMMAND_RECORDING_END},
     * {@link ForTheLibrary#PROC_COMMAND_RECORDING_START}
     * @return List of ProcessingCommands
     * @see ProcessingCommand
     */
    private List<ProcessingCommand> getProcessingCommands(int ... typesToGet)
    {        
        String urlString = Config.FTR_WEBSERVICE_URL+"/Scheduler/ProcessingCommands";                         
        try
        {
            String processingCommandsJson  = BTVHTTP.getResponseAsString(HTTP.POST(urlString, null));
            if(!valid(processingCommandsJson))
                throw new Exception("No data returned from: " + urlString);
            
            Type collectionType = new TypeToken<List<ProcessingCommand>>(){}.getType();
            List<ProcessingCommand> pcList = gson.fromJson(processingCommandsJson, collectionType);        
            if(pcList == null)
                throw new Exception("No processing commands returned from FTR");
            
            Logger.DEBUG("Found "+pcList.size() +" processing commands from FTR");
            //filter by type if requested
            if(typesToGet != null)//null means get all
            {
                //get as hashset for easy contains()
                Set<Integer> types = new HashSet<Integer>();
                for(int t :typesToGet) types.add(t);                                    
                    
                for(Iterator<ProcessingCommand> it = pcList.iterator(); it.hasNext();)
                {
                    ProcessingCommand pc = it.next();
                    if(pc.RunMode == null ||//cant determine what type
                    !types.contains(pc.RunMode.intValue()))//not a type we want
                    {
                        Logger.DEBUG("Removing Processing Command because type: "+ pc.RunMode +" is filtered out: "+ pc.Name);                    
                        it.remove();
                    }
                }
            }
            return pcList;
        }
        catch(Exception x)
        {
            Logger.ERROR("Failed to get processing commands from: "+urlString,x);
            return new ArrayList<ProcessingCommand>();//empty list
        }                                
    }
    
    /**
     * Gets a FTR Schedule from the FTR Webservice based on the schedule id.
     * @param scheduleId The id of the schedule to get
     * @return Schedule if found, otherwise null
     */
    private Schedule getScheduleById(String scheduleId)
    {        
        String urlString = Config.FTR_WEBSERVICE_URL+"/Scheduler/ScheduleById/"+scheduleId;    
        try
        {
            String scheduleJson = BTVHTTP.getResponseAsString(HTTP.POST(urlString, null));
            //NOTE: an invalid/old schedule id will return empty string
            if(!valid(scheduleJson))
                throw new Exception("No schedule found for id: "+ scheduleId); 
            
            Schedule schedule = gson.fromJson(scheduleJson, Schedule.class);
            return schedule;
        }
        catch(Exception x)
        {
            Logger.ERROR("Failed to get schedule from: "+urlString,x);
            return null;
        }                        
    }
    
    /**
     * Triggers update of XBMC's video library.<br>
     * If {@link  Config#UPDATE_ENTIRE_XBMC_LIBRARY} is false, then will trigger scan of any {@link Constants#TV_SHOWS_DIR} and/or {@link Constants#MOVIES_DIR} that
     * had videos archived in them<br>
     * If {@link  Config#UPDATE_ENTIRE_XBMC_LIBRARY} is true, will use JSON-RPC to trigger content scan of entire library (Eden+ compatible only)
     */
    private void triggerXBMCUpdate()
    {
        triggerContentScan:
        if(!Config.UPDATE_ENTIRE_XBMC_LIBRARY)
        {//we dont want to update the entire library, just the path(s) that have new recordings archived in them
            Logger.INFO("Determining paths to trigger XBMC content scan for the "+ archivedRecordings.size() +" recording(s)...");
            
            Set<String> pathsToTriggerScan = new HashSet<String>();
            
            for(File archivedRecording : archivedRecordings)
            {
                //TODO: possibly use a deeper directory for archived episodes (Season X dir)?... need to test if XBMC will scan this properly
                File tvShowOrMovieDir = getArchivedTopLevelParent(archivedRecording);//get base TV Show or Movie dir                
                if(tvShowOrMovieDir == null)
                    Logger.WARN("Failed to find TV Show/Movie parent dir for: "+ archivedRecording.getAbsolutePath());
                else
                {                
                    String uncPath = tvShowOrMovieDir.getAbsolutePath();                    
                    String xbmcPath = convertUNCToXBMCSmb(uncPath);                    
                    if(!valid(xbmcPath))
                    {
                        Logger.WARN("Failed to convert to XBMC path, skipping content scan on: "+ uncPath);
                        continue;
                    }                                
                    pathsToTriggerScan.add(xbmcPath);                    
                }
            }
            
            Logger.INFO("Will trigger scan for XBMC paths: "+pathsToTriggerScan);
            if(pathsToTriggerScan.isEmpty())
            {
                Logger.WARN("Could not determine which paths to trigger scan for. "
                        + "Skipping scan for specific directories. Will attempt to update entire XBMC library instead.");
                break triggerContentScan;
            }
            
            //use HTTP api to trigger scans of these path(s)
            //TODO: Move from depricated HTTP API when JSON-RPC supports scanning based on path instead of entire library updates (Post-Eden-Final)
            for(Iterator<String> it = pathsToTriggerScan.iterator(); it.hasNext();)
            {
                String path = it.next();         

                String url = Config.XBMC_WEBSERVER_URL
                        +"/xbmcCmds/xbmcHttp?"
                        + "command=ExecBuiltIn(UpdateLibrary(video,"+BTVTools.URLEncode(path)+"))";
                try
                {
                    String response = BTVHTTP.getResponseAsString(HTTP.POST(url, null));
                    /*Response looks like:
                     * <html>
                        <li>OK
                       </html>
                     */
                    if(valid(response) && response.contains("<li>OK"))
                        Logger.INFO("Successfully triggered content scan on XBMC directory: "+ path);
                    else
                        throw new Exception("Failed to trigger scan. XBMC response ="+LINE_BRK+response);

                    if(it.hasNext())
                    {//try and avoid double-triggering (which will just stop the ongoing scan -- not what we want)
                        
                        Logger.INFO("Waiting "+Config.XBMC_LIBRARY_UPDATE_SEC+" seconds before triggering next content scan (to give XBMC time to finish current scan).");
                        try{Thread.sleep(Config.XBMC_LIBRARY_UPDATE_SEC * 1000);}catch(InterruptedException ignored){}
                    }
                }
                catch(Exception x)
                {
                    Logger.ERROR("Failed to trigger XBMC content scan using command: "+ url,x);
                    Logger.NOTICE("Will attempt to update entire XBMC library instead.");
                    break triggerContentScan;
                }                
            }//done triggering content scans on dirs

            return;//no errors. We are done. No need to trigger entire library update.
            
        }//end triggerContentScan
        
        
        //Use this to trigger update of entire library using JSON-RPC.
        final String method = "VideoLibrary.Scan";//Note: Not compatible with Dharma. Eden+ only!
        Logger.INFO("Triggering update of XBMC's entire video library, using JSON-RPC method:" + method);
        final String jsonData = "{\"jsonrpc\": \"2.0\", \"method\": \""+method+"\", \"params\": {}, \"id\": \"1\"}";                                
        try
        {
            
            String response = BTVHTTP.getResponseAsString(HTTP.POST(Config.XBMC_WEBSERVER_URL+"/jsonrpc", jsonData, BTVHTTP.createHeaders("Content-Type", "application/json")));
            Logger.DEBUG("XBMC "+ method+" returned: "+response);
            JSONObject result = new JSONObject(response);
            String strResult;
            try{strResult = result.getString("result");}catch(Exception ig){strResult = "[no result available]";}
            
            if(!"OK".equalsIgnoreCase(strResult))//something went wrong
                throw new Exception("Invalid response from JSON-RPC "+ method +": "+ response);                            
        }
        catch(Exception x)
        {
            Logger.WARN("Failed to trigger XBMC VideoLibrary.Scan",x);
        }                  
    }
    
    
    
    /**
     * Finds all recordings in a directory. Recordings are based on if their extension is in {@link Config#VIDEO_EXTS_ARRAY}
     * @param recordingDir The directory to look for videos.
     * @return List of videos found
     * @throws Exception if error while checking or recording dir is invalid
     */
    private List<File> getRecordingsFromDirectory(File recordingDir) throws Exception
    {        
        if(!recordingDir.exists())
            throw new Exception("Directory does not exist: "+ recordingDir);
        if(!recordingDir.isDirectory())
            throw  new Exception("Directory is invalid: "+ recordingDir);

        List<File> recordings = new ArrayList<File>();
        for(Iterator<File> recordingIt = FileUtils.iterateFiles(recordingDir, Config.VIDEO_EXTS_ARRAY, true); recordingIt.hasNext();)
        {
            //filter some more... exclude the TV Shows and Movies directories
            File f = recordingIt.next(); 
            if(!isInsideRecordingShare(f))
            {
                Logger.WARN( "This file is not inside a recording share. Will not process: "+ f.getAbsolutePath()+LINE_BRK+
                        "Recording shares are: "+ RECORDING_SHARES);
                continue;
            }
            
            if(isArchived(f))//no warning because they may specify base recording share as dir
                continue;

            recordings.add(f);
        }        
        return recordings;
    }
        
    /**
     * Gets the closest parent {@link ForTheLibrary#RECORDING_SHARES} directory.
     * @param recordingFile The file to check
     * @return The closest {@link ForTheLibrary#RECORDING_SHARES} directory or null if none found.
     */
    private String getParentRecordingShare(File recordingFile)
    {
        if(TESTING)//testing, just use parent dir
            return recordingFile.getParentFile().getAbsolutePath();
        
        String fullpath = recordingFile.getAbsolutePath().toLowerCase();
        for(String share : RECORDING_SHARES)
        {
            //case-insensitive matching
            if(fullpath.startsWith(share.toLowerCase()))
                return share;
        }
        return null;
    }
    
    /**
     * Returns true if the file is inside one of the {@link ForTheLibrary#RECORDING_SHARES} directories.
     * @param recordingFile
     * @return 
     */
    private boolean isInsideRecordingShare(File recordingFile)
    {
        return valid(getParentRecordingShare(recordingFile));
    }
    
    /**
     * Finds the closest parent {@link Constants#TV_SHOWS_DIR} or {@link Constants#MOVIES_DIR} directory.
     * @param videoFile The file to check
     * @return closest {@link Constants#TV_SHOWS_DIR} or {@link Constants#MOVIES_DIR} parent directory or <code>null</code> if none are found.
     */
    private File getArchivedTopLevelParent(File videoFile)
    {         
         while(true)
         {
             File parentDir = videoFile.getParentFile();
             if(parentDir == null) break;//reached top, no match
             
             //if the name is exactly the same as our tv shows / movies archived dirs, we found a match
             if(parentDir.getName().equals(TV_SHOWS_DIR) || parentDir.getName().equals(MOVIES_DIR))
                 return parentDir;
             
             videoFile = parentDir;//go up another level
         }         
        return null;//no match after checking all parents
    }
    
    /**
     * Returns true if the File is already inside a {@link Constants#TV_SHOWS_DIR} or {@link Constants#MOVIES_DIR}
     * @param videoFile The file to check     
     */
    private boolean isArchived(File videoFile)
    {
       return getArchivedTopLevelParent(videoFile) != null;
    }        
    
    /**
     * Converts a Comskip .edl file from type 0 (Cut) to type 3 (Commercial Break)<br>
     * XBMC will treat type 3 .edls as commercial breaks instead of cutting content from the video.
     * @param edlFile The edl File to convert
     * @return true if was converted successfully or did not need to be converted. false if failed to convert
     */
    private boolean convertEdl(File edlFile)
    {
        /*  XBMC EDL Types:
            0 - Cut
            1 - Mute
            2 - Scene Marker (if start and end times are specified, the end time is used)
            3 - Commercial Break
         */
        final int EDL_TYPE = 3;        
        
        //read the current edl into memory and convert            
        List<String> lines = new ArrayList<String>();
        boolean needsConversion = false;
        
        Scanner s =null;
        try 
        {
            s = new Scanner(edlFile);
            while(s.hasNextLine())
            {
                String nextLine = s.nextLine();    
                if(!valid(nextLine)) continue;//skip any blank lines
                
                //edls are tab-seperated into 3 columns
                String convertedLine = nextLine.substring(0, nextLine.lastIndexOf("\t")) +"\t" + EDL_TYPE;                    
                lines.add(convertedLine);

                if(!needsConversion)//track if this is any different than the converted line
                    needsConversion = !convertedLine.equals(nextLine);//differences found                    
            }            
        }        
        catch(Exception x)
        {
            Logger.ERROR("Could not convert .edl to type " + EDL_TYPE + " at: " + edlFile.getAbsolutePath(),x);                
            return false;
        }
        finally
        {
            if(s != null) 
                s.close();
        }

        //overwrite the file with the converted lines                                 
        if(!needsConversion)
        {
            if(lines.isEmpty())
                Logger.INFO("SKIP: EDL file is empty and will not be converted: "+ edlFile.getAbsolutePath());
            else
                Logger.INFO("SKIP: EDL file is already type "+ EDL_TYPE +" at: "+ edlFile.getAbsolutePath());
            return true;
        }
                                        
        try  
        {
            boolean append = false;//overwrite the existing edl
            FileUtils.writeLines(edlFile, lines, append);            
            Logger.INFO("EDL CONVERTED: to type "+EDL_TYPE+": "+edlFile.getAbsolutePath());                    
            return true;
        }
        catch(Exception x)
        {
            Logger.ERROR("Failed to overwrite edl as type "+EDL_TYPE+": "+ edlFile.getAbsolutePath(),x);                             
            return false;
        }                
    }      
    
    /**
     * Joins partial recordings (spread across multiple) files using the {@link TSJoiner} (FFMpeg).
     * @param partialRecordings List of the files that make up the recording
     * @param recording The Recording object that is to be joined.
     * @param baseNameTrimmed The baneName to use for the newly joined recording. Will have .joined appended to it and use original extension.
     * @return true if joining was successful
     */
    private boolean joinPartialRecordings(List<File> partialRecordings, Recording recording, String baseNameTrimmed)
    {
        Logger.INFO("Will attempt to join "+ partialRecordings.size() +" recordings using FFMpeg located at: "+ Config.FFMPEG_EXE_PATH);
        
        //destination recording will be a new, joined file
        File lastPart = partialRecordings.get(partialRecordings.size()-1);//the last recording is the one we process
        File joinedRecording = new File(lastPart.getParentFile(), baseNameTrimmed+".joined."+recording.getExtension());
        String joinedBaseName = FilenameUtils.getBaseName(joinedRecording.getName());
       
        try
        {
             //do the join
            TSJoiner joiner = new TSJoiner(new File(Config.FFMPEG_EXE_PATH), joinedRecording);                                                

            for(File part : partialRecordings)//must be sorted correctly
                joiner.addVideo(part);
            boolean joined = joiner.join();//this is the typical problem spot since FFMpeg tends to have trouble joining OTA recordings
            if(!joined) 
            {//failed to join with ffmpeg
                if(joinedRecording.exists())//clean up any partially joined file that was created
                    joinedRecording.delete();
                
                throw new Exception("Failed to join");
            }
            else
                Logger.INFO("Successfully joined "+ partialRecordings.size() +" videos. Joined file is located at: "+ joinedRecording);

                                                
            //Update ftr with new location of joined recording (use last partial recording in list as the master recording. The rest will be deleted)            
            File recordingToUpdate = lastPart;
            try{ 
                updateFTRWithNewRecordingLoc(recordingToUpdate.getAbsolutePath(), joinedRecording.getAbsolutePath());                                
            }catch(Exception x){
                Logger.ERROR("Failed to update FTR with location of joined recording. Ending processesing for partial recording: " +lastPart, x);
                return false;
            }
            
            //we can delete this since it's been joined succesfully and ftr has already been updated with new location of joined file
            //this file is now just a leftover parts
            //note that the rest of the parts are deleted using FTR api which is more thorough/permanent
            //we dont want to do that with this recording because we are re-using its recording id for the joined recording
            Logger.INFO("Since FTR has been updated with new location of joined file, deleting partial file from disk: "+ recordingToUpdate);
            if(recordingToUpdate.delete())
                Logger.INFO("Successfully deleted leftover part file: "+ recordingToUpdate);
            else
                Logger.ERROR("Failed to delete leftover part. Please delete manually: "+ recordingToUpdate.getAbsolutePath());
            
            
            //Refresh the recording object now that it's moved. It seem ftr changes interal id's when things get moved???
            /*recording = getRecording(joinedRecording);
            if(recording == null)
            {
                Logger.ERROR("Failed to find recording after updating location. Cannot continue.");
                return false;
            }
             */

            
            //move original siblings to the be next to the new joined recording
            Logger.INFO("Moving all sibling files from: "+ recordingToUpdate + " to be next to joined recording: "+ joinedRecording);
            String siblingBaseName = FilenameUtils.getBaseName(recordingToUpdate.getName());//use full (non-trimmed) base name to get siblings of only this recording part
            Collection<File> originalSiblings = FileUtils.listFiles(recordingToUpdate.getParentFile(), getSiblingFilter(siblingBaseName, null),null);
            for(File sibling : originalSiblings)
            {                                
                //move next to joined recording using joined recording base name and original extension
                String siblingExt = FilenameUtils.getExtension(sibling.getName());
                File dest = new File(joinedRecording.getParentFile(), joinedBaseName+"."+siblingExt);

                try
                {
                    FileUtils.moveFile(sibling, dest);
                    Logger.INFO("Moved sibling from: "+ sibling +" to: "+ dest);
                }catch(Exception x)
                {
                    Logger.WARN("Failed to move sibling file:"
                            + LINE_BRK +"from: "+ sibling
                            + LINE_BRK +"to:   " + dest
                            ,x);
                }
            }                                                                
            
            
            //Clean up the old recordings by triggering delete in FTR (which also triggers any user-defined ProcessingCommands for before-delete)
            Logger.INFO("Deleting all partial recordings now that merged recording is available in FTR.");                            
            //dont delete the last recording because its now our new joined one.
            for(File recordingToDelete : partialRecordings.subList(0, partialRecordings.size()-1))//last recording is cut off in subList
            {                                 
                if(recordingToDelete.equals(recordingToUpdate))//should never happen since we sliced the list to cut off last file
                {
                    Logger.ERROR("Unexpected... Skipping attempted delete of recordingToUpdate.");
                    continue;
                }

                //trigger delete of partial recording (also triggers any processing commands for before-delete)
                boolean deleted = deleteRecording(recordingToDelete.getAbsolutePath());
                if(!deleted)
                    Logger.ERROR("Failed to delete partial recording: "+ recordingToDelete.getAbsolutePath());
            }

            
            //Now execute any post-processing commands that are available for new joined recording and notify if none are linked to the recording
            //We do this instead of processing the file now because user may have other things that need to be done for the newly joined file
            //such as comskip or CCExtraction.
            //By calling post-processing commands, this program will (should) get called again in a new instance for the joined recording            
            
            //save a map of all 4tr processing commands that are used at recording end or fixed time after recording end
            Logger.INFO("Getting list of all defined Recording-end processing commands.");
            Map<String, ProcessingCommand> recordingEndProcessingCommands = new LinkedHashMap<String, ProcessingCommand>();
            for(ProcessingCommand pc : getProcessingCommands(PROC_COMMAND_RECORDING_END, PROC_COMMAND_FIXED_TIME_AFTER_END))                            
                recordingEndProcessingCommands.put(pc.ProcessingCommandId, pc);//key of string id
            Logger.INFO("Found "+ recordingEndProcessingCommands.size() +" defined recording-end commands. Will now filter to only commands linked to this recording.");

            //get the specific processing commands used for this recording/schedule
            List<ProcessingCommand> recordingEndCommands = new ArrayList<ProcessingCommand>();
            Schedule schedule = getScheduleById(recording.getScheduleId());
            if(schedule == null)
            {
                //this can happen is a user stops a recording by selecting "Delete Schedule" for a one-time scheduled recording.
                Logger.WARN("Failed to determine schedule used for this recording. Will use all default recording-end processing commands.");                                        
                for(ProcessingCommand pc : schedule.ProcessingCommands)
                {
                    if(pc.IsDefaultTelevision)
                        recordingEndCommands.add(pc);//this is added by default, so assume it belongs to this schedule                    
                }                
               
            }
            else//found the schedule, now determine what processing commands in the schedule are processing end commands
            {
                for(ProcessingCommand pc : schedule.ProcessingCommands)
                {
                    if(recordingEndProcessingCommands.containsKey(pc.ProcessingCommandId))
                        recordingEndCommands.add(pc);//this is a end-of-the recording command. Save it.
                    //else, this is not used at end of recording. Ignore it since we don't need to run it again
                }
            }
            Logger.INFO("Found "+ recordingEndCommands.size() +" recording-end processing command(s) linked to this recording.");
            
            if(recordingEndCommands.isEmpty())
            {
                Logger.ERROR("No RECORDING_END processing commands were found for newly joined recording."+LINE_BRK
                        + "The joined recording will have to be manually processed (by you) using this application.");                                
                return false;
            }
            else//schedule the processing commands and finish
            {
                Logger.INFO("Will trigger "+ recordingEndCommands.size() +" recording-end ProcessingCommand(s) for the newly joined recording."
                    + LINE_BRK+"(These command(s) should start new instance of this program for the newly joined recording...)");

                boolean totalSuccess = true;
                for(ProcessingCommand command : recordingEndCommands)
                {
                    Date timeToRunAt;
                    if(command.RunMode != null && command.RunMode.intValue() == PROC_COMMAND_RECORDING_END)//no delay available for this type
                        timeToRunAt =  new Date();//now
                    else//delayed
                    {
                        int delayMin = command.RunAtMinutes == null || command.RunAtMinutes.intValue() < 0 ? 0 : command.RunAtMinutes.intValue();
                        int delayHour = command.RunAtHours == null || command.RunAtHours.intValue() < 0 ? 0 : command.RunAtHours.intValue();
                        long addedMs = delayMin * 60 * 1000;
                        addedMs += delayHour * 60 * 60 * 1000;
                        timeToRunAt = new Date(System.currentTimeMillis() + addedMs);                                        
                    }

                    //let the post-processing commands take care of the joined recording
                    Logger.INFO("Scheduling processing command \""+command.Name+"\" to be run at: "+ timeToRunAt);
                    boolean success = runProcessingCommandOnRecording(recording.getRecordingId(), command.ProcessingCommandId, timeToRunAt);
                    if(success)
                    {
                        Logger.INFO("Processing command successfully scheduled for recording id: "+ recording.getRecordingId());
                    }
                    else
                    {
                        totalSuccess = false;
                        Logger.ERROR("Failed to execute processing command: "+command.Name +" ["+command.CommandPath+"] ("+command.ProcessingCommandId+")");                                    
                    }
                }                               
                return totalSuccess;
            }                                                                                                                
        }
        catch(Exception x)
        {            
            Logger.WARN("Failed to join the "+ partialRecordings.size() +" partial recordings.",x);
            return false;
        }
    }
    
    /**
     * Uses default of <code>false</code> for useStartsWithInsteadOfEquals
     * @see ForTheLibrary#getSiblingFilter(String, Collection, boolean)      
     */
    public static IOFileFilter getSiblingFilter(final String baseName, final Collection<String> extensionFilter)
    {
        return getSiblingFilter(baseName, extensionFilter, false);//default to use equals
    }
    
    /**
     * Gets a IOFileFilter that only returns files that have the same baseName as specified. Can optionally filter based on extension as well.
     * @param baseName The base name of the file to match.
     * @param extensionFilter If not null, will only allow files that have these extensions (case-sensitive). Do not include . with ext
     * @param useStartsWithInsteadOfEquals Will match base name using starts with insead of equals. If true and a file's baseName starts with the baseName specified, will allow this file.
     * @return IOFileFilter that allows only siblings
     */
    public static IOFileFilter getSiblingFilter(final String baseName, final Collection<String> extensionFilter, final boolean useStartsWithInsteadOfEquals)
    {
        return new AbstractFileFilter() {
            @Override
            public boolean accept(File file) 
            {
              if(extensionFilter != null)
              {
                String ext = FilenameUtils.getExtension(file.getName());
                if(!extensionFilter.contains(ext))
                    return false;//we dont want this extension
              }
              //recording.getBaseName() must match
              String otherBaseName =FilenameUtils.getBaseName(file.getName());
              if(useStartsWithInsteadOfEquals)
                return otherBaseName.startsWith(baseName);
              else
                return baseName.equals(otherBaseName);
            }
        };
    }
}